CRUD REST API
- We want the frontend to be able to take some photos and upload them to the server. This means we are going to end up with something that a lot of java business applications end up with, ie. we want to be able to
- create photos,
- read photos (like getting them and seeing specific photos)
- update photos
- delete photos
- This means we are building a CRUD REST API (for "create, read, update, delete").
Step 1: the Photo model class
- Let's create a so-called
Photomodel class in your packagecom.companyname.phth.photoz.clone/- For now, let's just think of a photo as a combination of an
idand afilename. The image data will be added later (we just add a comment// raw dataso we do not forget this). - For now, we will put everything into one java package and in the end we will refactor it to make it look better
- add the following class
Photo.javaintocom.companyname.phth.photoz.clone/
- For now, let's just think of a photo as a combination of an
Press alt insert on or in the com.companyname.phth.photoz.clone/ package to add a new class to the package.
Also add getters and setters to the Photo class so that every third party dependency in Spring Boot that takes your Photos and converts them eg. to JSON (this is exactly what the get() method that we add next needs) will work without any workarounds and without any problems.
You can press alt insert in the editor inside the Photo class to quickly add getters and setters for each data member.
package com.companyname.phth.photoz.clone;
public class Photo {
private String id;
private String filename;
// raw data
public String getId() {
return id;
}
public void setId(String id) {
this.id = id;
}
public String getFilename() {
return filename;
}
public void setFilename(String filename) {
this.filename = filename;
}
}
Step 2: Read a Photo
- when writing a REST service it helps to "start from the top", which means that
- at some point what we want to have is a
@GetMapping("/photoz"), ie. we want ourPhotozControllerto react to the URL"/photoz"@GetMapping("/photoz")means that we want to return anyphoto(s) that our database has under the URL"/photoz"(at the moment we do not have a database yet - but this does not really matter, we just write out what we want our app to do)- we want to return the
photos as aList<Photo>, so theget()method that is mapped to"/photoz"should have the return typeList<Photo> - we do not have any
photos yet, so we add a list of photos in the form of aList<Photo>databasedb - to this database we add a new photo via
new Photo()(but for this we need a newPhoto()constructor)
- at some point what we want to have is a
package com.companyname.phth.photoz.clone;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@RestController
public class PhotozController {
private List<Photo> db = List.of(new Photo(/* TODO */));
@GetMapping("/")
public String hello() {
return "Hello World!";
}
@GetMapping("/photoz")
public List<Photo> get() {
/* TODO */
}
}
- the
new Photo()needs anidand afilename, so we have to add a new constructor to ourPhotoclass
Again, press alt insert in the editor to add new constructors to the Photo class. You can also choose which data members the constructor should initialize or to just add an empty constructor.
Always make sure to also have an empty constructor because, otherwise, you will have some issues with converting your java class to JSON and vice versa (see later).
package com.companyname.phth.photoz.clone;
public class Photo {
private String id;
private String filename;
public Photo() {
}
public Photo(String id, String filename) {
this.id = id;
this.filename = filename;
}
// raw data
public String getId() {
return id;
}
public void setId(String id) {
this.id = id;
}
public String getFilename() {
return filename;
}
public void setFilename(String filename) {
this.filename = filename;
}
}
- let's give the new
photoanidand afilenameand let theget()method return the list of (mocked) photosdb
package com.companyname.phth.photoz.clone;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@RestController
public class PhotozController {
private List<Photo> db = List.of(new Photo("1", "hello.jpg"));
@GetMapping("/")
public String hello() {
return "Hello World!";
}
@GetMapping("/photoz")
public List<Photo> get() {
return db;
}
}
- then, when we rerun (ctrl shift F5) the app we will get a success message because Spring is smart enough to understand that
get()should return our list of photos and then, internally, Spring Boot will take the list and convert it to JSON, so that we can see the list in our browser (in JSON formatas an array) - let's continue with GET methods
- next, we also want to be able to get a specific photo
- for convenience, let's duplicate (copy and paste) the
get()method because we want to change theGetMappingbecause we know that we want to have some sort of{id}parameter behind/photozto access a specific photo - at the moment we only have the
id="1", so everything else should give us a404which means that we have to parse this{id}somehow into the method, in other words the method should look like this:get(String id).- We do this by using the marker annotation
@PathVariablewhich tells Spring Boot that if there is some parameter with curly braces in the@GetMappingand if there is also a method parameter with the same name then Spring Boot should just substitute the parameter value into the method'sStringparameter
- We do this by using the marker annotation
package com.companyname.phth.photoz.clone;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@RestController
public class PhotozController {
private List<Photo> db = List.of(new Photo("1", "hello.jpg"));
@GetMapping("/")
public String hello() {
return "Hello World!";
}
@GetMapping("/photoz/{id}")
public List<Photo> get(@PathVariable String id) {
return db;
}
}
- our method should not return a
List<Photo>, but only one specificPhotoor a404 errormessage - problem: we cannot return our list of photos
dbany more- so, we will rewrite things a little bit because this will make it simpler in the long run
- instead of having a list of photos
List<Photo>we will have a map of photosMap<String, Photo>, so ourdbis now anew Hashmap<>(as always, make sure theimportis right) and we quickly (and dirtily) initialize theHashmapbynew HashMap<>() {{ put("1", new Photo("1", "hello.jpg")); }};and this will be our database - in the
Photo get(String id)method we just call a.get(id)on theHashmap, ie. we just return a specific photo - in the
List<Photo> get()method we call.values()on theHashmapwhich will give us all the different photos from theHashmap- problem:
.values()returns the photos as ajava.util.Collection- so, instead of
List<Photo> get()we will useCollection<Photo> get()
- so, instead of
- problem:
You can simply press altenter (VSCode: ctrl.) on .values() or click on IntelliJ's red light bulb and select "Make get() return java.util.Collection" to change the return type of get().
package com.companyname.phth.photoz.clone;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;
import java.util.Collection;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@RestController
public class PhotozController {
private Map<String, Photo> db = new HashMap<>() {{
put("1", new Photo("1", "hello.jpg"));
}};
@GetMapping("/")
public String hello() {
return "Hello World!";
}
@GetMapping("/photoz")
public Collection<Photo> get() {
return db.values();
}
@GetMapping("/photoz/{id}")
public Photo get(@PathVariable String id) {
/* TODO */
return db.get(id);
}
}
db.get(id)only works when we have a photo available, but when we do not have one available we do not want to return aNULLvalue, but we do want to return a404- in Spring we do that by throwing specific exceptions, eg., here, we can
throw new ResponseStatusException(HttpStatus.NOT_FOUND);
- in Spring we do that by throwing specific exceptions, eg., here, we can
package com.companyname.phth.photoz.clone;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
import java.util.Collection;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@RestController
public class PhotozController {
// ...
@GetMapping("/photoz/{id}")
public Photo get(@PathVariable String id) {
Photo photo = db.get(id);
if (photo == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND);
return photo;
}
}
Step 3: Delete a Photo
- let's add a method to delete a
Photowhen we receive a DELETE request:- for convenience, we just copy and paste the
get(@PathVariable String id)method and modify it
- for convenience, we just copy and paste the
package com.companyname.phth.photoz.clone;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
import java.util.Collection;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@RestController
public class PhotozController {
// ...
@DeleteMapping("/photoz/{id}")
public void delete(@PathVariable String id) {
Photo photo = db.remove(id);
if (photo == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND);
}
}
- problem: so far we have executed GET calls by refreshing the page, but how do we execute a DELETE call? How do we delete a
Photousing the browser?- IntelliJ has a REST Client which we could use in the IntelliJ Ultimate Edition
- instead, we will use the bare bones version of a DELETE request by using the JavaScript Fetch API
- w3schools: JS Fetch API
- mdn: Fetch API
- "The Fetch API provides a JavaScript interface for making HTTP requests and processing the responses."
- "Fetch is the modern replacement for
XMLHttpRequest: unlikeXMLHttpRequest, which uses callbacks, Fetch is promise-based and is integrated with features of the modern web such as service workers and Cross-Origin Resource Sharing (CORS)."
XMLHttpRequest: stackoverflow: http-get-request-in-javascript
- run the following JavaScript statement using the DevTools/JavaScript Console of your browser
In Firefox before running the following JavaScript code you have to (temporarily) disable devtools.jsonview.enabled in about:config, otherwise Content Security Policy (CSP) will block this request, stackoverflow
(async function deletePhoto(id) {
await fetch("http://localhost:8080/photoz/" + id, {
method: "DELETE"
})
})("5")
Here, "5" is passed as the id to the method deletePhoto(id).
- the console will output a
404because thePhotowithid="5"does not exist - what happens if we send a DELETE request for the
id="1"instead ofid="5"?- then, when we refresh the
http://localhost:8080/photoz/1page the page will show a404error message because the photo withid="1"is gone and the app just returned an empty list ofPhotos - when we load the
http://localhost:8080/photozpage (without the1at the end) we will see the empty list ofPhotos - this is exactly what we wanted
- then, when we refresh the
Step 4: Create a Photo
- again, for convenience, we just copy and paste the
delete()method - the frontend should not generate the
id, but we should generate theid- for now, we will set the
idto someUUID.randomUUID()(a random long string)
- for now, we will set the
- we also return the
Photo(effectively, the JSON object) with the newly setid - we will not do any validation for now (→ Step 5)
After writing return photo; you can just hit altenter (VSCode: ctrl.) to change the return type of the create() method from void to Photo more quickly.
Don't worry about formatting. After writing the code you can always press ctrlaltl (VSCode: altshiftf) to reformat the code. Press this shortcut frequently to keep your code well-formatted.
package com.companyname.phth.photoz.clone;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
import java.util.Collection;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
@RestController
public class PhotozController {
private Map<String, Photo> db = new HashMap<>() {{
put("1", new Photo("1", "hello.jpg"));
}};
@GetMapping("/")
public String hello() {
return "Hello World!";
}
@GetMapping("/photoz")
public Collection<Photo> get() {
return db.values();
}
@GetMapping("/photoz/{id}")
public Photo get(@PathVariable String id) {
Photo photo = db.get(id);
if (photo == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND);
return photo;
}
@DeleteMapping("/photoz/{id}")
public void delete(@PathVariable String id) {
Photo photo = db.remove(id);
if (photo == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND);
}
@PostMapping("/photoz")
public Photo create(Photo photo) {
photo.setId(UUID.randomUUID().toString());
db.put(photo.getId(), photo);
return photo;
}
}
- restart the webserver
- let's send a POST request, again, via the JavaScript Fetch API
let photo = {"fileName": "hello.jpg"};means we have onephotowhich should be created- here, we are not sending in an
"id", but only a"filename"
- here, we are not sending in an
- we are executing a
fetch()call tolocalhost:8080/photoz - we are going to send in a
"Content-Type": "application/json"because we need to tell Spring Boot what we are sending to it- Spring Boot will then understand that
"application/json"means that it is getting JSON and that it needs to convert the JSON to a javaPhotoobject
- Spring Boot will then understand that
body: JSON.stringify(photo)means we will send in thephotoas aStringin thebody- finally, we print out whatever we get back from the server with
.then(result => result.text()).then(text => alert(text));
There was a typo in the Fetch API POST Request: instead of "fileName" you have to use "filename".
(async function createPhoto() {
// wrong: let photo = {"fileName": "hello.jpg"};
let photo = {"filename": "hello.jpg"};
await fetch("http://localhost:8080/photoz", {
method: "POST",
headers: {
"Accept": "application/json",
"Content-Type": "application/json"
},
body: JSON.stringify(photo)
})
.then(result => result.text())
.then(text => alert(text));
})();
Note: You can copy and paste this including all comments into the console. Below is the same code without comments.
(async function createPhoto() {
let photo = {"filename": "hello.jpg"};
await fetch("http://localhost:8080/photoz", {
method: "POST",
headers: {
"Accept": "application/json",
"Content-Type": "application/json"
},
body: JSON.stringify(photo)
})
.then(result => result.text())
.then(text => alert(text));
})();
- this will output an alert popup
{"id":"caf92f05-abeb-4c1f-8acf-76dc83eaadab","filename":null}in the browser - when we refresh
http://localhost:8080/photozthe page will show[{"id":"1","filename":"hello.jpg"},{"id":"caf92f05-abeb-4c1f-8acf-76dc83eaadab","filename":null}] - this looks almost perfect, the new photo was created, but the
"filename"isnull, why?- this is a common beginner's mistake: you should not pass the
Photoobject as argument to thecreate()method, but instead, since we are sending in JSON data, we need to use, again, a marker annotation, namely@RequestBodyto tell Spring Boot that it should take the JSON data and convert it to thePhotoclass
- this is a common beginner's mistake: you should not pass the
- Always make sure that you have the right marker annotations in these methods and RestControllers.
- important annotations you should remember:
@RequestBody@PathVariable@GetMapping@PostMapping
package com.companyname.phth.photoz.clone;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
import java.util.Collection;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
@RestController
public class PhotozController {
// ...
@PostMapping("/photoz")
public Photo create(@RequestBody Photo photo) {
photo.setId(UUID.randomUUID().toString());
db.put(photo.getId(), photo);
return photo;
}
}
- let's check if this fixes the previous
"filename":nullproblem - restart the webserver
- note: we are resetting our database with every restart
- execute the JavaScript
createPhoto()function again - this time, the page at
http://localhost:8080/photozwill show[{"id":"1","filename":"hello.jpg"},{"id":"c34c3c26-bec1-4c31-b941-877fe68ef32b","filename":"hello.jpg"}- this means we have two photos in our database
- if you execute the POST request again, it will show
[{"id":"8a022ae2-9eaf-48db-87c2-8ec1fa4787f8","filename":"hello.jpg"},{"id":"1","filename":"hello.jpg"},{"id":"c34c3c26-bec1-4c31-b941-877fe68ef32b","filename":"hello.jpg"}]- this means we have three photos in our database and we already get some JSON messages back
Step 5: Validation
- right now we are only uploading metadata and not real image data and we will change that below, but before that let's do one other important thing
- we have no validation yet
- at the moment someone can send an
"id"and it gets overwritten anyway with a random"id"(thus, the"id"field is safe), but, the issue is, you can send in a photo without a"filename"- try this with the POST request we used previously by writing
let photo = { };instead oflet photo = {"filename": "hello.jpg"};- this will add a photo to the database that has the filename
null - how do we prevent this from happening?
- this will add a photo to the database that has the filename
- try this with the POST request we used previously by writing
- we need an additional dependency:
- open the
pom.xmlfile - as you can see, all Spring Boot dependencies (in our project,
spring-boot-starter-data-jdbcandspring-boot-starter-web) have the samegroupId,<groupId>org.springframework.boot</groupId> - what we need is
spring-boot-starter-validation, so we add the following lines to ourpom.xml
- open the
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<!-- ... -->
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jdbc</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<!-- ... -->
</project>
- then click the "Load Maven Changes" icon in the upper right corner so that IntelliJ reloads the
pom.xmlfile automatically (IntelliJ will download all dependencies and will index them) - now your app has validation capabilities
- now you can add the
@Validannotation to thePhoto photoargument of thePhoto create()method- what does
@Validmean?- Eg. you can specify
@NotEmptyon thefilenamein thePhotoclass which means that thefilenameshould not be empty - there are many more annotations like
@NotEmpty - when you look at the
jakarta.validation.constraintspackage (click onconstraintsinimport jakarta.validation.constraints.NotEmpty;while pressing and holding ctrl, this will open thejakarta.validation.constraintsfolder in the file explorer where you can findNotEmpty.javaand other validation constraints) you can see that there are other marker annotations such as@DecimalMax,@Email(to make sure something is a valid email),@Min,@Max,@Negative,@NotEmpty,@Null,@Past(if you have dates),@Positive,@PositiveOrZero, etc - so, you can annotate you fields with different annotations and then Spring Boot will be able to automatically validate whatever the user puts into your object/into the JSON
- Eg. you can specify
- what does
package com.companyname.phth.photoz.clone;
import jakarta.validation.constraints.NotEmpty;
public class Photo {
private String id;
@NotEmpty
private String filename;
public Photo() {
}
// ...
}
package com.companyname.phth.photoz.clone;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
import java.util.Collection;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
@RestController
public class PhotozController {
// ...
@PostMapping("/photoz")
public Photo create(@RequestBody @Valid Photo photo) {
photo.setId(UUID.randomUUID().toString());
db.put(photo.getId(), photo);
return photo;
}
}
- restart the webserver and execute the POST request we used previously with the empty filename
let photo = { };again - this will show an alert popup
{"timestamp":"2024-07-21T05:13:34.148+00:00","status":400,"error":"Bad Request","path":"/photoz"}- this is a
404which says"Bad Request"which is excactly what we wanted because it says something was wrong with our picture (aka the picture was not valid)
- this is a
- next, to check if a valid POST request still works, you can try to POST a photo with some new filename, eg. by using
let photo = {"filename": "helloWorld.jpg"};instead oflet photo = { };- this works and will add a new photo to the database
Step 6: Uploading a real photo as a base64 string (instead of just metadata)
- for this we will use the browser's file chooser capabilities
- first, create a new static html page called
upload.htmlinsrc/main/resources/static/(press altinsert on thestatic/folder to create the html file)- the html file has
- one
<input />field: a file input field so that we can choose your files - an upload button which executes an
uploadFile()method which is defined in the html file, toouploadFile()is anasyncfunction which executes a so-called HTTP multipart request with someFormData()(here: your file) tohttp://localhost:8080/photozuploadFile()is just a JavaScript Fetch API POST request like the one we did above, but unlike above we are not sending JSON any more, but instead we are just letting the browser send a file- the browser will send the raw data and the filename with the whole request
- one
- the html file has
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
<h2>Photo Upload</h2>
<input id="fileupload" type="file" name="fileupload" />
<button id="upload-button" onclick="uploadFile()"> Upload </button>
<script>
async function uploadFile() {
let formData = new FormData();
formData.append("data", fileupload.files[0]);
await fetch('http://localhost:8080/photoz', {
method: "POST",
body: formData
}).then(result => result.text())
.then(text => alert(text));
}
</script>
</body>
</html>
- this means we need to change our
PhotozControllerbecause at the moment thecreate()method takes@RequestBody @Valid Photo photoas argument, but we wantMultipartFileas argument which is a Spring Boot specific class- when you upload a file Spring will convert the uploaded file to a
MultipartFile - again, you need a marker annotation,
@RequestPart("somePart"), which requests a part from a multipart file upload- you need to tell Spring Boot which part contains your file
formData.append("data", fileupload.files[0])means that we are sending someformData, ie. our filefileupload.files[0], under the key"data"- therefore, we have to refer to the key
"data"to get our file - we could have called the key differently, but here we just chose to call it
"data"
- you need to tell Spring Boot which part contains your file
- when you upload a file Spring will convert the uploaded file to a
When the cursor is placed somewhere inside parentheses press altshiftright (multiple times) to quickly select everything inside the parentheses. Eg. you can use this to replace @RequestBody @Valid Photo photo with @RequestPart("data") MultipartFile file.
- so, we are getting a
MultipartFile filein and at some point we have to create a photo, hence we writePhoto photo = new Photo(); - next we have to set the
idandfilenameofphoto:- the
randomUUIDis still fine - the
filenamehas to be the original filename that the browser sends in, so we usefile.getOriginalFilename()which is a method that is a part of theMultipartFileclass
- the
- we also want to store the raw data of our photo:
- we will store the raw data in a database later
- we could also store the raw data in a file or whatever
- for now, we will store the raw data in memory because this is the simplest to demonstrate
- for this, we have to add the raw data to the
Photoclass:- for simplicity's sake, we just add a
bytearray which contains the photo's dataprivate byte[] data; - as always do not forget to also add getters and setters for
data(by pressing altinsert)
- for simplicity's sake, we just add a
- for this, we have to add the raw data to the
- finally, we can just use the method
file.getBytes()of theMultipartFileclass to set thedataof the newly createdPhotoobjectphoto, ie. we writephoto.setData(file.getBytes());- this will throw an exception (red light bulb), but we can simply add a
throws IOExceptionto the method signature to propagate the exception out to the caller of thecreate()method (see java exceptions) because for now, we do not want to worry about thefile.getBytes()function
- this will throw an exception (red light bulb), but we can simply add a
In-memory database: When we upload a 100MB file to our app then the memory usage will increase by 100MB!
package com.companyname.phth.photoz.clone;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.server.ResponseStatusException;
import java.io.IOException;
import java.util.Collection;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
@RestController
public class PhotozController {
// ...
@PostMapping("/photoz")
public Photo create(@RequestPart("data") MultipartFile file) throws IOException {
Photo photo = new Photo();
photo.setId(UUID.randomUUID().toString());
photo.setFilename(file.getOriginalFilename());
photo.setData(file.getBytes());
db.put(photo.getId(), photo);
return photo;
}
}
- restart the webserver
- at
http://localhost:8080/photoz: the photo we have stored in our database has no data, but that is fine because we did not set any data for that one - go to
http://localhost:8080/upload.html- Spring Boot is smart enough to put
upload.htmlat this URL because we putupload.htmlinsrc/main/resources/static - click "Browse..." and choose and upload some file
- if the chosen file is large enough then the "Run" window in IntelliJ will show a
.MaxUploadSizeExceededException:
- if the chosen file is large enough then the "Run" window in IntelliJ will show a
- Spring Boot is smart enough to put
2024-07-22T01:17:15.504+02:00 WARN 367078 --- [photoz-clone] [io-8080-exec-10] .w.s.m.s.DefaultHandlerExceptionResolver : Resolved [org.springframework.web.multipart.MaxUploadSizeExceededException: Maximum upload size exceeded]
2024-07-22T01:17:15.504+02:00 WARN 367078 --- [photoz-clone] [io-8080-exec-10] .w.s.m.s.DefaultHandlerExceptionResolver : Resolved [org.springframework.web.multipart.MaxUploadSizeExceededException: Maximum upload size exceeded]
- to fix this we will add
.max-file-size=100MBand.max-request-size=100MBto thesrc/main/resources/application.propertiesfile:
spring.application.name=photoz-clone
spring.servlet.multipart.max-file-size=100MB
spring.servlet.multipart.max-request-size=100MB
- now try to upload the large image again
- you will see that the image is uploaded successfully
- the
datafield contains the image bytes converted to base64 string encoding - at
http://localhost:8080/photozyou can see the whole image as a string - but we do not want to get this string data and this is what we are going to fix next
Step 7: Uploading a real photo and downloading it (or displaying it)
- there is two things to it
- first, when we get the metadata we want that Spring does not include the
"data"field and sends it back to the frontend - Spring uses a library called
jacksonfor the JSON conversion- In the
Photoclass use ajacksonspecific marker annotation@JsonIgnoreon thedatafield of thePhotoclass. This will prevent the conversion ofdatato JSON whenever we send a photo back to the frontend. So, we will not see the photo.
- In the
package com.companyname.phth.photoz.clone;
import com.fasterxml.jackson.annotation.JsonIgnore;
import jakarta.validation.constraints.NotEmpty;
public class Photo {
private String id;
@NotEmpty
private String filename;
@JsonIgnore
private byte[] data;
public Photo() {
}
public Photo(String id, String filename) {
this.id = id;
this.filename = filename;
}
// raw data
// ...
}
- At the moment we can upload photos, but we do not get the data back (ie. we cannot download the data). But we want to see a real photo in our browser. How do we do that?
- For this, we need to refactor the app quite a bit.
- There are several ways to approach this.
DEPENDENCY INJECTION: Inject PhotozService into PhotozController and DownloadController
- let's say we want to have another additional controller called
DownloadController- it is, again, a
@RestController - the only thing this controller does is the following
- it has a
@GetMapping("/download/{id}")whereidis supposed to be theidof the photo we want to download and see in our browser - this
@GetMappingwill send raw bytes back to the browser- we could return
byte[], but instead, here we will returnResponseEntity<byte[]>because we want to send back not only the bytes, but also some HTTP headers including thefilenameof the file that we are requesting - so we want to request a specific photo and send back a
ResponseEntity<byte[]>in turn, so we writepublic ResponseEntity<byte[]> download(@PathVariable String id) - we want it to return
ResponseEntity<>(data, headers, HttpStatus.OK);wheredatawill be our image bytes andHttpStatus.OKstatus will be send back when everything worked correctly - we create the
datathat comes from our photo in our database withbyte[] data; - we also want some
headersso we could createMultiValueMap<String, String> headers;, but there is also the specific classHttpHeadersthat the Spring Framework offers, so we better writeHttpHeaders headers = new HttpHeaders(); - but how do we get the photo data now into
byte[] data;?- this is the first time we will learn something about dependency injection
- we could return
- it is, again, a
package com.companyname.phth.photoz.clone;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.util.MultiValueMap;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class DownloadController {
@GetMapping("/download/{id}")
public ResponseEntity<byte[]> download(@PathVariable String id) {
byte[] data;
HttpHeaders headers = new HttpHeaders();
return new ResponseEntity<>(data, headers, HttpStatus.OK);
}
}
- problem: since the database variable
dbisprivateto thePhotozControllerclass we cannot access it in theDownloadController.- we could make it
publicor instead, we could observe the following:- when we layer our apps then we have our controllers and what they do is they convert JSON back to some object or they extract the
PathVariables and they send back some JSON - but what they do not do is they do not talk to the database
- for the latter task, we could create a so-called service class
PhotozService- again, we need to tell Spring that this is a special class and that Spring should do something with it: we want Spring to instantiate this class for us whenever Spring boot up the app
- we do this with the marker annotations
@Componentand@Service(they both do the vey same thing, it is just that@Serviceis more human readable, but you can choose either one of those two annotations) - here, we just add
@Serviceto thePhotozServiceclass - now, at startup Spring knows that it not only has to look at the REST controllers, but also at the
PhotozServicebecause it needs to be instantiated as a service - next, we can move the
dbdefinition from thePhotozControllerclass to thePhotozServiceclass
- inside the
PhotozControllerclass:- now, instead of asking the database
Mapobject inside thePhotozControllerclass to give somedbvalues or to give the photo for a specificidor to remove something, we let thePhotozControllerask thePhotozServiceby addingprivate final PhotozService photozService;to thePhotozControllerclass- we also add a constructor (by using IntelliJ context actions ctrl.) which receives a
PhotozServiceand sets thatphotozServiceinside thePhotozController
- we also add a constructor (by using IntelliJ context actions ctrl.) which receives a
- then, in
get()instead of asking the Hashmapdbinreturn db.values();we will ask thephotozServiceby writingreturn photozService.get();which is a method we still have to create (by using IntelliJ context actions ctrl.)
- now, instead of asking the database
- when we layer our apps then we have our controllers and what they do is they convert JSON back to some object or they extract the
- we could make it
package com.companyname.phth.photoz.clone;
import org.springframework.stereotype.Component;
import org.springframework.stereotype.Service;
import java.util.HashMap;
import java.util.Map;
//@Component
@Service
public class PhotozService {
private Map<String, Photo> db = new HashMap<>() {{
put("1", new Photo("1", "hello.jpg"));
}};
}
package com.companyname.phth.photoz.clone;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.server.ResponseStatusException;
import java.io.IOException;
import java.util.Collection;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
@RestController
public class PhotozController {
private final PhotozService photozService;
public PhotozController(PhotozService photozService) {
this.photozService = photozService;
}
@GetMapping("/")
public String hello() {
return "Hello World!";
}
@GetMapping("/photoz")
public Collection<Photo> get() {
return photozService.get();
}
// ...
}
Now, in PhotozController.java you can see that you have some delegation inside the PhotozController class: The PhotozController does not know about the database any more, but the PhotozController only knows about the PhotozService. The PhotozController asks the PhotozService to return all the photos the PhotozService knows. It does not matter if the photos come from a database or from a filesystem (eg. S3) or whatever, the PhotozController does not change.
How does the PhotozController get a PhotozService instance? You already know that. On startup Spring scans your entire package and it has a look at specific classes like @RestController. If Spring finds a @RestController it creates a new @RestController in which the methods respond to HTTP calls. The same thing happens when Spring finds a constructor with some requirement, eg. in the PhotozController class Spring will find the constructor public PhotozController(PhotozService photozService) which has the requirement PhotozService photozService, ie. the constructor needs a PhotozService. This is so-called constructor injection. Then, Spring will scan your classes and look for a class named PhotozService (and it is even annotated with the @Service annotation). So, Spring will find PhotozService and create a new PhotozService, just a single instance of it (singleton). Finally, Spring will inject it into the PhotozController so that the PhotozController can call any method on that service as it wants.
There are different injection styles in Spring, but they all do the same thing, namely inject a PhotozService into the PhotozController:
constructor injection:
private final PhotozService photozService;
public PhotozController(PhotozService photozService) {
this.photozService = photozService;
}
field injection (style 1):
@Autowired
private final PhotozService photozService;
field injection (style 2):
private final PhotozService photozService;
public PhotozController(@Autowired PhotozService photozService) {
this.photozService = photozService;
}
- next, the
DownloadControllerneeds the same thing, so we just copy and paste the lines we added to thePhotozControllerclass:- after copying and pasting you have to fix a small error, the
PhotozControllerconstructor needs to be renamed toDownloadController(by using IntelliJ context actions ctrl.)
- after copying and pasting you have to fix a small error, the
- now, the
DownloadControlleralso gets aPhotozServiceinjected and can use thePhotozServiceto retrieve a specific photo
package com.companyname.phth.photoz.clone;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class DownloadController {
private final PhotozService photozService;
public DownloadController(PhotozService photozService) {
this.photozService = photozService;
}
@GetMapping("/download/{id}")
public ResponseEntity<byte[]> download(@PathVariable String id) {
byte[] data;
HttpHeaders headers = new HttpHeaders();
return new ResponseEntity<>(data, headers, HttpStatus.OK);
}
}
- in the
PhotozControllerclass we need to replace all calls ofdbwithphotozService - but
photozServicedoes not have the methods that the Hashmapdbhad like eg. the methoddb.remove(),db.put(), etc. - so we have to create those methods
Let IntelliJ create the missing methods for you (by using IntelliJ context actions ctrl.).
package com.companyname.phth.photoz.clone;
import org.springframework.stereotype.Component;
import org.springframework.stereotype.Service;
import java.util.Collection;
import java.util.HashMap;
import java.util.Map;
//@Component
@Service
public class PhotozService {
private Map<String, Photo> db = new HashMap<>() {{
put("1", new Photo("1", "hello.jpg"));
}};
public Collection<Photo> get() {
return null;
}
public Photo get(String id) {
}
public Photo remove(String id) {
}
public void save(String id, Photo photo) {
}
}
- so, what should these methods do?
- mostly, we can just copy and paste the code that was in the
PhotozControllerclass before - in
save()we have to rearrange a little bit
package com.companyname.phth.photoz.clone;
import org.springframework.stereotype.Service;
import java.util.Collection;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
//@Component
@Service
public class PhotozService {
private Map<String, Photo> db = new HashMap<>() {{
put("1", new Photo("1", "hello.jpg"));
}};
public Collection<Photo> get() {
return db.values();
}
public Photo get(String id) {
return db.get(id);
}
public Photo remove(String id) {
return db.remove();
}
public Photo save(String fileName, byte[] data) {
Photo photo = new Photo();
photo.setId(UUID.randomUUID().toString());
photo.setFilename(fileName);
photo.setData(data);
db.put(photo.getId(), photo);
return photo;
}
}
package com.companyname.phth.photoz.clone;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.server.ResponseStatusException;
import java.io.IOException;
import java.util.Collection;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
@RestController
public class PhotozController {
private final PhotozService photozService;
public PhotozController(PhotozService photozService) {
this.photozService = photozService;
}
@GetMapping("/")
public String hello() {
return "Hello World!";
}
@GetMapping("/photoz")
public Collection<Photo> get() {
return photozService.get();
}
@GetMapping("/photoz/{id}")
public Photo get(@PathVariable String id) {
Photo photo = photozService.get(id);
if (photo == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND);
return photo;
}
@DeleteMapping("/photoz/{id}")
public void delete(@PathVariable String id) {
Photo photo = photozService.remove(id);
if (photo == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND);
}
@PostMapping("/photoz")
public Photo create(@RequestPart("data") MultipartFile file) throws IOException {
return photozService.save(file.getOriginalFilename(), file.getBytes());
}
}
- now the
PhotozControllerand theDownloadControllerare using the very same instance of thePhotozServiceand that is what the whole constructor injection and dependency injection topic is about
Test the DEPENDENCY INJECTION
- let's also fix our
DownloadControllernow, so we can quickly test our changes - as a "quick and dirty" fix, we will temporarily change
byte[] data;tobyte[] data = new byte[0];(by using IntelliJ context actions ctrl.), but we will change this later
// imports
@RestController
public class DownloadController {
private final PhotozService photozService;
public DownloadController(PhotozService photozService) {
this.photozService = photozService;
}
@GetMapping("/download/{id}")
public ResponseEntity<byte[]> download(@PathVariable String id) {
byte[] data = new byte[0];
HttpHeaders headers = new HttpHeaders();
return new ResponseEntity<>(data, headers, HttpStatus.OK);
}
}
- now let's quickly check if our dependency injection is working
- restart the webserver
http://localhost:8080/photozwill show[{"id":"1","filename":"hello.jpg"}], so we got rid of the"data"field that we had beforehttp://localhost:8080/photoz/1will show{"id":"1","filename":"hello.jpg"}, so we can still search for a specific photo- we can also still get all photos or remove the photos
- that means our dependency injection is working perfectly
Fix the DownloadController
- now, let's continue fixing our
DownloadController - at the moment our
download()method gets in aString id, so we will try to get a photo for that specificidby writingPhoto photo = photozService.get(id); - again, we have to check if the
photoexists and if it does not exist we have to throw aHttpStatus.NOT_FOUNDResponseStatusException - now, we can set the
datasimply tophoto.getData() - the line
return new ResponseEntity<>(data, headers, HttpStatus.OK);means that we can almost download the photo now, but the only thing that is still missing in there are theheaders - there are
two thingsthree things we need to do in order to set theheaders(set theContentType,filenameandContentDisposition):- we need to tell that our photo is of a specific
ContentType, ie. we need to say whether we have.jpg,.pngor whatever have you - we can set this
ContentTypeby callingheaders.setContentType(); - problem: we do not store the
ContentTypein ourPhotoclass yet, so we have to add it (with getters and setters)Photo.javapackage com.companyname.phth.photoz.clone;
import com.fasterxml.jackson.annotation.JsonIgnore;
import jakarta.validation.constraints.NotEmpty;
public class Photo {
private String id;
@NotEmpty
private String filename;
private String contentType;
// ...
public String getContentType() {
return contentType;
}
public void setContentType(String contentType) {
this.contentType = contentType;
}
} - then, in the
DownloadControllerwe can just get theContentTypeby calling the Spring specific methodMediaType.valueOf()(you kind of have to know all these methods by heart, don't worry if you think "how did he come up with that?") and we want to sendphoto.getContentType()back to the browser, so summarizing we writeheaders.setContentType(MediaType.valueOf(photo.getContentType()));- so, now the browser knows if it is a
.gifor a.pngor whatever
- so, now the browser knows if it is a
- there is another thing the browser needs: the filename
- at the moment, the browser does not have a filename, so we need to send a header with a filename
- we also need to tell the browser if the browser should display the photo in the browser or automatically download it
- you do this with
headers.setContentDisposition(); - in order to set a
ContentDispositionyou need to build a new one by callingContentDisposition.builder().build()(again, just a Spring specific wrapper around HTTP specific header methods) - then, you can just write
ContentDisposition.builder("attachment").build()to tell the browser that it should download the photo instead of displaying it in the browser (otherwise, if you want to display the photo you could just writeContentDisposition.builder("inline").build()) - we specify the filename by adding
ContentDisposition.builder("attachment").filename(photo.getFilename()).build();
- you do this with
- we need to tell that our photo is of a specific
Press ctrlaltv to introduce a new variable for a selected expression. Eg., here, you can select ContentDisposition.builder().build() and then press ctrlaltv to introduce a new variable for the newly built ContentDisposition.
package com.companyname.phth.photoz.clone;
import org.springframework.http.*;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
@RestController
public class DownloadController {
private final PhotozService photozService;
public DownloadController(PhotozService photozService) {
this.photozService = photozService;
}
@GetMapping("/download/{id}")
public ResponseEntity<byte[]> download(@PathVariable String id) {
Photo photo = photozService.get(id);
if (photo == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND);
byte[] data = photo.getData();
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.valueOf(photo.getContentType()));
ContentDisposition build = ContentDisposition
.builder("attachment")
.filename(photo.getFilename())
.build();
headers.setContentDisposition(build);
return new ResponseEntity<>(data, headers, HttpStatus.OK);
}
}
- the last thing we need to do is storing the
ContentTypeusing thePhotozService- the browser uploads the file with the filename and the
ContentType, we just need to save theContentTypeonce the file is created
- the browser uploads the file with the filename and the
- for this, we add a third argument to the
photozService.save();call in thecreate()method of thePhotozControllerclass by writingphotozService.save(file.getOriginalFilename(), file.getContentType(), file.getBytes());- and then we let IntelliJ add a third parameter
String ContentTypeto the oldphotozService.save()method (by using IntelliJ context actions ctrl. onphotozService.save();) so thatphotozService.save();can actually be called with three arguments - then, we can set the
ContentTypein thesave()method of thePhotozServiceclass by addingphoto.setContentType(contentType);to thesave()method
- and then we let IntelliJ add a third parameter
// imports
@RestController
public class PhotozController {
// ...
@PostMapping("/photoz")
public Photo create(@RequestPart("data") MultipartFile file) throws IOException {
return photozService.save(file.getOriginalFilename(), file.getContentType(), file.getBytes());
}
}
// imports
//@Component
@Service
public class PhotozService {
// ...
public Photo save(String fileName, String contentType, byte[] data) {
Photo photo = new Photo();
photo.setId(UUID.randomUUID().toString());
photo.setFilename(fileName);
photo.setContentType(contentType);
photo.setData(data);
db.put(photo.getId(), photo);
return photo;
}
}
Test the App
- restart the webserver
- try the following tests:
http://localhost:8080/photoz/1: the photo is here, but it has nocontentType. That is absolutely fine because this is our mock photo.http://localhost:8080/upload.html:- upload a file
- the alert box will show eg.
{"id":"20d04d80-a85a-4066-875c-45d8c6c5dbb6","filename":"Angela_Merkel_freigestellt_ohneKopf.png","contentType":"image/png"}- so, the
filenameandcontentTypework flawlessly
- so, the
- to check if the
idworks- copy the
idshown in the alert box and go to the download URL, eg.http://localhost:8080/download/20d04d80-a85a-4066-875c-45d8c6c5dbb6 - a photo will be downloaded by the browser
- check if the downloaded photo is the one you uploaded before,
- result: this works
- copy the
Recap
- we have built
- a couple of controllers, namely
DownloadControllerandPhotozController - an application class,
PhotozCloneApplication - a service,
PhotozService - a so-called model class,
Photo
- a couple of controllers, namely
Step 8: Refactor the project
- at the moment, this all lives in one folder, or rather in one package
- we want to refactor the project a bit so that it is a bit more idiomatic
- one way to do that is to introduce new subpackages, eg.
web(for the controllers),service(for thePhotozService.java),model(forPhoto.java), whereas the applicationPhotozCloneApplication.javastays at the top - there are more ways to structure this, but for our tiny package we can take this as a good starting structure